{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<img src=\"../common/rfsoc_book_banner.jpg\" alt=\"University of Strathclyde\" align=\"left\">"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Notebook Set I\n",
    "\n",
    "---\n",
    "\n",
    "## 02 - OFDM Python Transceiver\n",
    "\n",
    "Following on from OFDM Fundamentals, this notebook introduces the major components of an OFDM transceiver. This includes a demonstration of multipath channel estimation and the one-tap or Zero Forcing (ZF) equaliser. \n",
    "\n",
    "* Review major steps in OFDM transmitter \n",
    "* Pass signal through multipath channel and add AWGN noise \n",
    "* Review major steps in OFDM receiver \n",
    "* Demonstrate channel estimation and the one-tap equaliser\n",
    "\n",
    "##  Table of Contents \n",
    "\n",
    "* [1. Introduction](#introduction)\n",
    "* [2. OFDM Transmitter](#ofdmtx)\n",
    "   * [2.1. Training Symbol Generation](#training)\n",
    "   * [2.2. OFDM Symbol Generation](#data)\n",
    "* [3. Wireless Channel](#channel)\n",
    "* [4. OFDM Receiver](#ofdmrx)\n",
    "   * [4.1. Channel Estimation](#chanest)\n",
    "   * [4.2. Channel Equalisation](#chaneq)\n",
    "* [5. Conclusion](#conclusion)\n",
    "\n",
    "## Revision\n",
    "* **v1.0** | 05/12/22 | *First Revision*\n",
    "\n",
    "---\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 1. Introduction <a class=\"anchor\" id=\"introduction\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In this notebook, an overview of the main steps in an OFDM transceiver are provided. On the transmit side, this includes symbol generation, IFFT modulation and Cyclic Prefix (CP) addition. The OFDM signal is then passed through a baseband channel model, including multipath channel filter and Additive White Gaussian Noise (AWGN). The receiver stages include CP removal, FFT demodulation, channel estimation and one-tap equalisation. In order to assess the quality of the received signal, we inspect the received constellations before and after equalisation. \n",
    "\n",
    "We will also mention some important aspects of the OFDM transceiver which are not explicitly covered in this notebook, such as timing and frequency synchronisation.    "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2. OFDM Transmitter <a class=\"anchor\" id=\"ofdmtx\"></a>\n",
    "\n",
    "In this section, we will demonstrate some of the main steps in an OFDM transmitter. Firstly, let's import the necessary libraries and helper functions:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Import necessary libraries \n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import math\n",
    "from scipy import signal\n",
    "\n",
    "# Import helper function\n",
    "from rfsoc_book.helper_functions import symbol_gen, psd, \\\n",
    "frequency_plot, scatterplot, calculate_evm, awgn"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Figure 1 shows the stages of the OFDM transmitter that will be demonstrated in this notebook:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<figure>\n",
    "<img src=\"images/ofdm_tx_3 copy.svg\" style=\"width: 1000px;\"/> \n",
    "    <figcaption><b>Figure 1: OFDM transmitter design.</b></figcaption>\n",
    "</figure>    "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "This is very similar to the final OFDM transmitter diagram shown in [OFDM Fundamentals](01_ofdm_fundamentals.ipynb). However, we have now addded a sub-carrier mapping stage after symbol generation. This is necessary because in real OFDM systems, only particular sub-carriers are used to carry data. The remaining sub-carriers are used to carry pilots for phase tracking and channel estimation in the receiver and null sub-carriers to relax the requirements for anti-imaging and anti-aliasing filters in the transmitter and receiver respectively.  \n",
    "\n",
    "In this notebook, we will use the OFDM symbol structure and sub-carrier mapping scheme employed in the IEEE 802.11a/g (Wi-Fi) standard. However, we will restrict ourselves to the addition of null sub-carriers, since we will not demonstrate phase tracking and will employ training symbols for channel estimation. \n",
    "\n",
    "Let's set some important OFDM parameters: "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fs = 20e6 # Sampling rate \n",
    "N = 64 # No. of sub-carriers "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2.1. Training Symbol Generation <a class=\"anchor\" id=\"training\"></a>\n",
    "\n",
    "As mentioned previously, channel estimation will be performed using training symbols, known to both transmitter and receiver. This will be based on the Legacy Long Training Field (L-LTF), used in the IEEE 802.11a/g standard. The L-LTF consists of two training OFDM symbols (each with length $N$ = 64) preceded by a 32 sample CP. As a result, it is 160 samples long.  \n",
    "\n",
    "The generation of the L-LTF is based on the following sequence:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "#L-LTF sequence\n",
    "LTFseq = np.array([0,0,0,0,0,0,1,1,-1,\\\n",
    "                  -1,1,1,-1,1,-1,1,1,1,\\\n",
    "                  1,1,1,-1,-1,1,1,-1,1,\\\n",
    "                  -1,1,1,1,1,0,1,-1,-1,\\\n",
    "                  1,1,-1,1,-1,1,-1,-1,\\\n",
    "                  -1,-1,-1,1,1,-1,-1,1,\\\n",
    "                  -1,1,-1,1,1,1,1,0,0,0,\\\n",
    "                  0,0])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In reference to Figure 1, we have already completed the symbol generation and sub-carrier mapping stages. A total of 52 Binary Phase Shift Keying (BPSK) symbols have been generated (1,-1) and have been mapped to 52 OFDM sub-carriers. The remaining 12 sub-carriers are set to zero and these are the null sub-carriers. This includes the DC sub-carrier at the centre of the spectrum, 6 on the left edge of the frequency band and 5 on the right edge.   \n",
    "\n",
    "We can now perform the IFFT to generate the training symbol:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "LTFsymb = np.fft.ifft(np.fft.fftshift(LTFseq),N)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "An FFT shift is performed to map the symbols to the correct IFFT bins. This is because the IFFT operates from $[0, f_{s})$, whereas the sub-carriers are mapped assuming a frequency range $[-f_{s}/2, f_{s}/2)$.  "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Finally, to generate the L-LTF, we repeat the symbol twice and add a 32 sample CP. "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Extract 32 sample CP \n",
    "LTFcp = LTFsymb[32:64]\n",
    "\n",
    "# Concatenate to form L-LTF\n",
    "LLTF = np.concatenate((LTFcp, LTFsymb, LTFsymb))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The L-LTF is used for channel estimation in this notebook. However, it can also be used for fine timing synchronisation, fine frequency synchronisation and integer frequency offset estimation in the receiver. In the IEEE 802.11a/g standard, another training symbol called the Legacy Short Training Field (L-STF) is also used. This will not be covered here. However, it can be used for Automatic Gain Control (AGC) convergence, timing synchronisation and frequency offset estimation.   "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2.2. OFDM Symbol Generation <a class=\"anchor\" id=\"data\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Having created the L-LTF, we will now go on to generate our data payload. This consists of a variable number of OFDM symbols carrying randomly generated (BPSK,QPSK,16-QAM) data symbols. We will add a CP of length $N/4 = 16$ samples to each OFDM symbol.\n",
    "Let's generate a block of $N_{ofdm}N_{data}$ data symbols, where $N_{ofdm}$ is the number of OFDM symbols in the payload and $N_{data}$ is the number of data carrying sub-carriers:  "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "n_ofdm = 1000 # No. of OFDM symbols\n",
    "n_data = 52 # No. of data sub-carriers\n",
    "nsym = n_ofdm * n_data # No. of data symbols\n",
    "mod_scheme = 'QPSK' # Modulation scheme\n",
    "\n",
    "# Generate data symbols\n",
    "data = symbol_gen(nsym,mod_scheme)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We will now perform sub-carrier mapping and IFFT modulation to obtain the OFDM symbols. The data symbols will be mapped in the same manner as the L-LTF sequence and there will be null sub-carriers at DC and on the band edges."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Indices for data sub-carriers \n",
    "ind_1 = np.arange(start=6, stop=32)\n",
    "ind_2 = np.arange(start=33, stop=59)\n",
    "index = np.concatenate((ind_1, ind_2), axis=0)\n",
    "\n",
    "# Initialisation of array to hold OFDM symbols \n",
    "ofdm_data = np.zeros(n_ofdm*N,np.complex64)\n",
    "j = 0 \n",
    "k = 0 \n",
    "\n",
    "for i in range(n_ofdm):\n",
    "    \n",
    "    # Initialise array to hold data and null sub-carriers\n",
    "    # (all null to begin with)\n",
    "    sc_array = np.zeros(N,np.complex64)\n",
    "    \n",
    "    # Map data symbols to correct sub-carrier positions\n",
    "    sc_array[index] = data[j:j+n_data] \n",
    "    \n",
    "    # Perform IFFT modulation\n",
    "    ofdm_data[k:k+N] = np.fft.ifft(np.fft.fftshift(sc_array),N) \n",
    "    \n",
    "    # Increment\n",
    "    j = j + n_data\n",
    "    k = k + N"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We will now add the 16 sample CP to the beginning of each OFDM symbol:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Define function to add CP \n",
    "def add_cp(ofdm_symb,N,cp_len):\n",
    "    \n",
    "    #Extract CP\n",
    "    cp = ofdm_symb[N-cp_len:N:1]\n",
    "    \n",
    "    # Concatenate CP and symbol \n",
    "    ofdm_symb_cp = np.concatenate((cp,ofdm_symb))\n",
    "    \n",
    "    return ofdm_symb_cp\n",
    "\n",
    "cp_len = N // 4 # CP length is 1/4 of symbol period\n",
    "\n",
    "# Add CP to each of the ofdm symbols \n",
    "ofdm_data_cp = np.zeros(n_ofdm*(N+cp_len),np.complex64)\n",
    "j = 0\n",
    "k = 0 \n",
    "\n",
    "for i in range(n_ofdm):    \n",
    "    ofdm_data_cp[k:(k+N+cp_len)] = add_cp(ofdm_data[j:j+N],N,cp_len)\n",
    "    j = j + N  \n",
    "    k = k + N + cp_len "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In order to create the final transmit signal, we attach the L-LTF at the beginning of the data payload. As such, the L-LTF is transmitted first:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Concatenate L-LTF and data payload to form final transmit signal \n",
    "txSig = np.concatenate((LLTF,ofdm_data_cp))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 3. Wireless Channel <a class=\"anchor\" id=\"channel\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "At this stage, we will pass the OFDM signal through a baseband model of the wireless channel. The channel is comprised of a multipath filter and AWGN as illustrated below:  "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<figure>\n",
    "<img src=\"images/channel copy.svg\" style=\"width: 700px;\"/> \n",
    "    <figcaption><b>Figure 2: Baseband channel model. </b></figcaption>\n",
    "</figure>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "For the multipath channel filter, we will employ a standard 4-tap FIR filter with complex weights drawn from a zero mean normal distribution. This is similar to the procedure used to simulate a Rayleigh Fading channel. With a 4 tap FIR, $d_{s}$ is equal to 3 sampling periods, meaning that the CP of length 16 is more than sufficient."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Filter coefficients\n",
    "ntap = 4\n",
    "h = np.random.randn(ntap) + np.random.randn(ntap)*1j\n",
    "\n",
    "# Appy channel filter \n",
    "txSig_filt = np.convolve(txSig, h)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The AWGN is added with a power that is calculated based on a desired SNR: "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "SNR = 25 # Desired SNR (dB) \n",
    "rxSig = awgn(txSig_filt,SNR)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 4. OFDM Receiver <a class=\"anchor\" id=\"ofdmrx\"></a>\n",
    "\n",
    "An illustration of the OFDM receiver steps is shown below: \n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<figure>\n",
    "<img src=\"images/ofdm_rx copy.svg\" style=\"width: 1000px;\"/> \n",
    "    <figcaption><b>Figure 3: OFDM receiver design.</b></figcaption>\n",
    "</figure>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We will start by extracting the L-LTF symbols and demodulating them using the FFT. These will be used for channel estimation purposes. In this example, we know the exact timing of the beginning of each symbol. However, in practice, this is unknown and an appropriate timing synchronisation algorithm must be used to acquire symbol timing. Also, due to oscillator offsets between transmitter and receiver and Doppler effects, a frequency synchronisation stage must be performed prior to the FFT in order to minimise the potential for Inter Carrier Interference (ICI).  \n",
    "\n",
    "The beginning of the first L-LTF OFDM symbol is sample 33 i.e. immediately after the 32 sample CP: "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Extract received L-LTF OFDM symbols \n",
    "rx_LLTF_symb_1 = rxSig[32:96]\n",
    "rx_LLTF_symb_2 = rxSig[96:160]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's now take the FFT of each of them to recover the transmitted sequences: "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "LLTF_symb_demod_1 = np.fft.fftshift(np.fft.fft(rx_LLTF_symb_1,N))\n",
    "LLTF_symb_demod_2 = np.fft.fftshift(np.fft.fft(rx_LLTF_symb_2,N))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ok, we'll leave these here for now and return to them when we do channel estimation. Now let's extract the data payload OFDM symbols and perform FFTs to recover the underlying data symbols. The CP is removed because it does not contain any information."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Function to demodulate OFDM \n",
    "def ofdm_demod(ofdm_rx,N,cp_len):\n",
    "    \n",
    "    # Remove CP \n",
    "    ofdm_u = ofdm_rx[cp_len:(N+cp_len)]\n",
    "    \n",
    "    # Perform FFT \n",
    "    data = np.fft.fftshift(np.fft.fft(ofdm_u,N))\n",
    "    \n",
    "    return data\n",
    "\n",
    "# Array to hold recovered  data symbols  \n",
    "data_rx = np.zeros(n_ofdm*n_data,np.complex64)\n",
    "j = 0\n",
    "k = 0 \n",
    "\n",
    "# Extract data payload (after end of L-LTF)\n",
    "L = len(rxSig)\n",
    "rxPayload = rxSig[160:L:1]\n",
    "\n",
    "# Demodulate OFDM symbols in payload \n",
    "for i in range(n_ofdm):\n",
    "    \n",
    "    # Demodulate OFDM symbols \n",
    "    rx_demod = ofdm_demod(rxPayload[k:(k+N+cp_len)],N,cp_len)\n",
    "    \n",
    "    # Extract data symbols \n",
    "    data_rx[j:j+n_data] = rx_demod[index] \n",
    "    \n",
    "    j = j + n_data\n",
    "    k = k + N + cp_len "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4.1. Channel Estimation <a class=\"anchor\" id=\"chanest\"></a>\n",
    "\n",
    "At this stage, we will perform channel estimation. Recall that the channel effect is reduced to a single complex multiplication per sub-carrier. The received symbol on sub-carrier $k$, $Y[k]$, in any given OFDM symbol is given by:    "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "$$ Y[k] = H[k]X[k] + W[k],$$"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "where $X[k]$ is the $k^{th}$ transmitted data symbol, $H[k]$ is the frequency response at sub-carrier $k$ and $W[k]$ is the noise at sub-carrier $k$.  "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The data symbols transmitted in the L-LTF symbols are known to the receiver. Therefore, we can estimate the channel by dividing through by $X[k]$, leading to:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "$$ \\hat{H}[k] = \\frac{Y[k]}{X[k]} = H[k] + \\frac{W[k]}{X[k]} = H[k] + \\alpha_{k}, $$"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "where $\\hat{H}[k]$ is the $k^{th}$ channel estimate. The quantity $W[k]/X[k]$ is an unwanted noise term which we denote as $\\alpha_{k}$. Since we have two OFDM symbols in the L-LTF, we can generate two channel estimates. Let's extract the symbols on the data sub-carriers, since the null sub-carriers don't carry any information:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Extract data sub-carriers\n",
    "LLTF_data_1 = LLTF_symb_demod_1[index]\n",
    "LLTF_data_2 = LLTF_symb_demod_2[index] "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Get channel estimates \n",
    "h_1 = LLTF_data_1 / LTFseq[index]\n",
    "h_2 = LLTF_data_2 / LTFseq[index]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Since the channel effect is not changing over time, we can average $h_{1}$ and $h_{2}$ to produce a final channel estimate:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Average h_1 and h_2 to get final estimate \n",
    "h_final = (h_1 + h_2) / 2"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Taking an average of the two estimates reduces the effects of noise. As alluded to previously, using the L-LTF for channel estimation assumes that the channel response does not change between the start of the L-LTF and the end of the data payload. This is a reasonable assumption for typical Wi-Fi deployments.  \n",
    "\n",
    "Let's now compare the estimated magnitude and phase responses of the channel at the data sub-carriers, with the actual magnitude and phase responses of the channel at these positions:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Calculate magnitude and phase response of channel \n",
    "chan = np.fft.fftshift(np.fft.fft(h,N))\n",
    "chan_data = chan[index]\n",
    "\n",
    "# Plot actual and estimated magnitude response\n",
    "plt.plot(abs(chan_data),'b')\n",
    "plt.plot(abs(h_final),'--r')\n",
    "plt.title('Actual and Estimated Magnitude Response')\n",
    "plt.xlabel('Sub-carrier index (data)')\n",
    "plt.ylabel('Magnitude (Linear)')\n",
    "plt.legend(('Actual','Estimated'))\n",
    "\n",
    "plt.figure(2)\n",
    "\n",
    "# Plot actual and estimated phase response\n",
    "plt.plot(np.angle(chan_data),'b')\n",
    "plt.plot(np.angle(h_final),'--r')\n",
    "plt.title('Actual and Estimated Phase Response')\n",
    "plt.xlabel('Sub-carrier index (data)')\n",
    "plt.ylabel('Angle (radians)')\n",
    "plt.legend(('Actual','Estimated'))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The sub-carrier index has been changed to 0 to 51, since there are a total of 52 data sub-carriers. As you decrease the SNR, you'll notice that the difference between the actual and estimated frequency responses increases. This is because when the noise power increases, $\\alpha_{k}$ also increases. As shown above, the contribution of $\\alpha_{k}$ can be reduced by averaging the channel estimates for the $k^{th}$ sub-carrier over time. The number of available channel estimates to be averaged depends on how regularly the channel is estimated and the length of time over which the channel repsonse remains constant.     "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4.2. Channel Equalisation <a class=\"anchor\" id=\"chaneq\"></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In OFDM, there are two common equalisation methods; the one-tap or Zero Forcing (ZF) equaliser and the Minimum Mean Square Error (MMSE) equaliser. At this stage, we will restrict our discussion to the ZF equaliser. The MMSE will be introduced in a further iteration of the notebook.   "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "The one-tap or ZF equaliser is the most computationally efficient equalisation method. It is given by: "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "$$ \\hat{X}[k] = \\frac{Y[k]}{\\hat{H}[k]}. $$"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Before expanding the above equation, let's assume that sufficient time averaging of channel estimates has been performed such that $\\alpha_{k} = 0$. This will simplify the expanded expression. Therefore, we arrive at:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "$$ \\hat{X}[k] = \\frac{Y[k]}{\\hat{H}[k]} = \\frac{X[k]H[k] + W[k]}{H[k]} = X[k] + \\frac{W[k]}{H[k]}.$$"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Inspecting the above, we see that the equalised symbol includes an error term, $W[k]/H[k]$. This error term has a detrimental effect on the performance of the ZF equaliser because when $|H[k]|$ is close to zero, i.e. when the channel is in a deep fade, the $W[k]$ or noise term is amplified. Moreover, as the noise power increases, the amplification has a more severe effect. These issues lead to an SNR degradation after the ZF equaliser."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Based on the procedure for complex division, the ZF equaliser can be re-written as:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "$$ \\hat{X}[k] = Y[k]\\frac{\\hat{H}^{*}[k]}{|\\hat{H}[k]|^{2}}, $$"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "where * denotes complex conjugation. The term $\\frac{\\hat{H}^{*}[k]}{|\\hat{H}[k]|^{2}}$ is referred to as the ZF equaliser gain. \n",
    "\n",
    "Let's now inspect the received constellation before applying the ZF equaliser.  "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "scrolled": true,
    "tags": []
   },
   "outputs": [],
   "source": [
    "# Plot constellation \n",
    "scatterplot(data_rx.real,data_rx.imag,ax=None)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "It is clear that the received constellations are heavily distorted. Without equalisation, this would lead to a large number of symbol and bit errors. In the next cell, we will equalise the data symbols using the channel estimate from the previous section:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Equalise data symbols \n",
    "data_eq_zf = np.zeros(n_ofdm*n_data,np.complex64)\n",
    "j = 0\n",
    "\n",
    "for i in range (n_ofdm):\n",
    "    \n",
    "    data_eq_zf[j:j+n_data] = data_rx[j:j+n_data] * (np.conj(h_final)/abs(h_final)**2)\n",
    "    \n",
    "    j = j + n_data"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Plot constellation \n",
    "scatterplot(data_eq_zf.real,data_eq_zf.imag,ax=None)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Try running the notebook several times and look at the received constellation after ZF equalisation. You will notice that even for high SNRs, the constellation can still be very distorted. This becomes especially evident when employing 16-QAM. This is due to the noise amplification that occurs when the  channel experiences deep fades. Have a look at the channel magnitude response to confirm the presence (or not!) of a deep fade. Note, in practice, the use of Forward Error Correction (FEC) in OFDM systems can help to improve the error rate performance in channels with deep fades. It is also possible to reduce the modulation order on sub-carriers affected by a deep fade to improve robustness, if there is a mechanism in place to feedback the Channel State Information (CSI) to the transmitter. \n",
    "\n",
    "The noise amplification issue associated with the ZF equaliser can be reduced by employing an MMSE equaliser, albeit with an increase in computational complexity.         "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 5. Conclusion <a class=\"anchor\" id=\"conclusion\"></a>\n",
    "\n",
    "In this notebook, we have demonstrated the major steps in an OFDM transceiver chain. This includes symbol generation, IFFT modulation and CP addition on the transmit side and CP removal, FFT demodulation, channel estimation and one-tap equalisation on \n",
    "the receive side. The equalised constellations were plotted as a measure of received signal quality.  "
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "[⬅️ Previous Notebook](01_ofdm_fundamentals.ipynb) || [Next Notebook 🚀](03_rfsoc_ofdm_transceiver.ipynb)\n",
    "\n",
    "Copyright © 2023 Strathclyde Academic Media\n",
    "\n",
    "---\n",
    "---"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.8.2"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
